<?php

/**
 * MNet Library 
 * Copyright (C) 2006-2008 Catalyst IT Ltd (http://www.catalyst.net.nz)
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 *
 * @package    mnet
 * @subpackage core
 * @author     Catalyst IT Ltd
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL
 * @copyright  (C) 2006-2008 Catalyst IT Ltd http://catalyst.net.nz
 * @copyright  (C) portions from Moodle, (C) Martin Dougiamas http://dougiamas.com
 *
 */

define('MNET_DIR', dirname(__FILE__));

/**
 * This is the base class of MNet, the inter-Moodle communication protocol.
 * 
 * This class represents the environment of the local MNet host, and provides
 * some core MNet capabilities, such as key generation and signing/encryption
 * of payloads
 */
class mnet {
    private $keypair_pem; // stores our current public/private keypair in PEM string format
    private $pubkey_cert; // stores our current X.509 public key certificate string

    /**
     * This is the bootstrap function for the MNet protocol. Before using MNet, you must call
     * this method statically and provide an instance of a class extending mnet_system_base and
     * implementing the required functionality for MNet to operate in the current environment, then
     * pass an array of instanciated extension objects which either extend standard core extension
     * classes, or are completely custom, in which case they only need extend mnet_extension_base
     * 
     * @param mnet_system_base $system_implementation an mnet_system_base-derived object implementing the local environment
     * @param array $extension_implementations an array of instanciated extension implementations that ultimately extend mnet_extension_base
     * @return bool true if the API initialised successfully, otherwise it will issue a PHP warning and return false
     */
    public static function init($system_implementation, $extension_implementations) {
        global $MNET, $MNET_APPLICATION, $MNET_EXTENSIONS;

        if (isset($MNET)) {
            throw new mnet_exception('MNet has already been initialised, check global $MNET before calling init');
        }

        // include some commonly-needed parts of MNet, although not the server,
        // which should be loaded only on demand
        require_once MNET_DIR.'/mnet_application.php';
        require_once MNET_DIR.'/mnet_message.php';
        require_once MNET_DIR.'/mnet_peer.php';
        require_once MNET_DIR.'/mnet_extension.php';
        require_once MNET_DIR.'/mnet_client.php';

        if (!is_subclass_of($system_implementation, 'mnet_application')) {
            throw new mnet_exception('The passed application implementation is not derived from mnet_application');
        }

        $MNET_APPLICATION = $system_implementation;
        $MNET_EXTENSIONS = array();

        foreach ($extension_implementations as $extension) {
            if (!is_subclass_of($extension, 'mnet_extension')) {
                throw new mnet_exception('Extension '.get_class($extension).' does not extend mnet_extension');
            }
            $MNET_EXTENSIONS[] = $extension;
        }

        // Finally, intanciate a singleton of this class
        $MNET = new mnet();

        // Successful only if the local keypair was successfully initialised
        if (!isset($MNET->keypair_pem)) {
            unset($MNET);
            return false;
        }

        return true;
    }

    /**
     * This is a private constructor to set up the MNet environment. It should only
     * ever be called from mnet::init() as this is a single object stored in global $MNET.
     * 
     * During initialisation, the API will check that there is a host entry for ourselves
     * and (re)generate keys if required.
     *
     */
    private function __construct() {
        // Ensure we have a valid keypair and certificate
        $this->init_local_keypair();
    }

    /**
     * Loads our current keypair and certificate from the database.
     * 
     * If we don't have a keypair yet, or our certificate has expired,
     * then we'll generate a new keypair.
     * 
     * The results will be stored in $this->keypair_pem and $this->pubkey_cert
     *
     * @see get_public_key()
     * @see get_private_key()
     * @return bool true on success, false with an error logged otherwise
     */
    private function init_local_keypair() {
        global $MNET_APPLICATION;

        if (!extension_loaded('openssl')) {
            $MNET_APPLICATION->log(MNET_LOG_ERR, 'OpenSSL extension not loaded');
            return false;
        }

        // has the keypair already been loaded?
        if (!empty($this->keypair_pem) && !empty($this->pubkey_cert)) {
            return true;
        }

        // Load the currently configured certificate and PEM, which we'll use to
        // create the OpenSSL resource
        $keypair = $MNET_APPLICATION->get_current_keypair();
        if (empty($keypair) || $this->certificate_is_expired($keypair['certificate'])) {
            // we need to regenerate the keypair since it's either non-existent or expired
            if ($this->regenerate_local_keypair()) {
                return true;
            }

            $MNET_APPLICATION->log(MNET_LOG_ERR, 'Keypair generation appeared to fail');
            return false;
        }

        $this->keypair_pem = $keypair['keypair_PEM'];
        $this->pubkey_cert = $keypair['certificate'];

        return true;
    }

    /**
     * This method will generate a new keypair from scratch and push it to the top
     * of the history of keypairs for the local host.
     * 
     * The new keypair OpenSSL resource will be stored in $this->keypair ready for use
     * 
     * Ordinarily this should not be called from outside, but there may be instances where
     * you would like to force a rekey, for example if your keys may have been compromised.
     *
     * @return array the generated keypair (also stored in $this->keypair)
     */
    public function regenerate_local_keypair() {
        global $MNET_APPLICATION;

        $keypair = $this->generate_keypair($MNET_APPLICATION->get_local_wwwroot());

        // store the local public key back, which will also shuffle the history
        // along if necessary
        $MNET_APPLICATION->set_new_keypair($keypair['certificate'], $keypair['keypair_PEM']);

        $this->keypair_pem = $keypair['keypair_PEM'];
        $this->pubkey_cert = $keypair['certificate'];

        $MNET_APPLICATION->log('info', 'New public key has been generated. It expires ' . date('Y/m/d h:i:s', $this->get_certificate_expiry($keypair['certificate'])));

        return $keypair;
    }

    /**
     * Returns the current local private key
     * 
     * @return string the local keypair PEM string (contains public and private key)
     */
    public function get_keypair_pem() {
        global $MNET_APPLICATION;

        if (empty($this->keypair_pem)) {
            $this->init_local_keypair();
        }
        if (empty($this->keypair_pem)) {
            // no keypair exists and we're unable to create one
            $MNET_APPLICATION->log(MNET_LOG_ERR, 'Unable to find a private key for local site');
            return null;
        }

        return $this->keypair_pem;
    }

    /**
     * Returns the local host's current public key
     * 
     * @return string the public key certificate in X.509 format
     */
    public function get_public_key_cert() {
        global $MNET_APPLICATION;

        if (empty($this->pubkey_cert)) {
            $this->init_local_keypair();
        }
        if (empty($this->pubkey_cert)) {
            // no keypair exists and we're unable to create one
            $MNET_APPLICATION->log(MNET_LOG_ERR, 'Unable to find public key');
            return null;    
        }

        return $this->pubkey_cert;
    }

    /**
     * Returns our current wwwroot
     * 
     * @return string the configured wwwroot for the local host
     */
    public function get_local_wwwroot() {
        global $MNET_APPLICATION;
        return $MNET_APPLICATION->get_local_wwwroot();
    }

    /**
     * Checks if the provided X.509 certificate is past its expiry
     *
     * @param string $certificate the certificate in X.509 string format
     * @return bool true if it has expired
     */
    public function certificate_is_expired($certificate) {
        return $this->get_certificate_expiry($certificate) < time();
    }

    /**
     * Parses a public key certificate
     *
     * Includes a 'wwwroot' key, which is the wwwroot of the host that generated the cert
     *
     * @param string a certificate in X.509 format
     * @return array public key information from OpenSSL, or false on failure
     */
    public function parse_certificate($certificate) {
        global $MNET_APPLICATION;
        
        $credentials = openssl_x509_parse($certificate);
        if ($credentials == false) {
            $MNET_APPLICATION->log(MNET_LOG_ERR, 'Credentials failed to parse');
            return false;
        }

        if (array_key_exists('subjectAltName', $credentials['subject'])) {
            $credentials['wwwroot'] = $credentials['subject']['subjectAltName'];
        } else {
            $credentials['wwwroot'] = $credentials['subject']['CN'];
        }

        return $credentials;
    }
    
    /**
     * Returns the time when a provided certificate expires
     * 
     * @param string a certificate in X.509 format
     * @return int the expiry time, UNIX time
     */
    public function get_certificate_expiry($certificate) {
        $parsed = $this->parse_certificate($certificate);
        if (!$parsed) {
            return 0;
        }

        return $parsed['validTo_time_t'];
    }

    /**
     * General helper to generate new public/private keypairs for the local host
     *
     * Uses the distinguished name provided to create a CSR, and then sign that CSR
     * with the same credentials.
     * If a distinguished name is not provided, details for creating the certificate
     * will be gleaned from the environment.
     *
     * @param string $wwwroot the wwwroot to create, used for the DN
     * @param int $days how many days the certificate should be valid for
     * @return array  an assoc array new keypair, with keys 'certificate' and 'keypair_PEM'
     */
    public function generate_keypair($wwwroot, $days = 28) {
        global $MNET_APPLICATION;

        $keypair = array();
    
        $country       = $MNET_APPLICATION->get_country();
        $locality      = $MNET_APPLICATION->get_locality();
        $email         = $MNET_APPLICATION->get_certificate_email();
        $organisation  = $MNET_APPLICATION->get_organisation_name();

        $dn = array(
           'countryName' => $country,
           'stateOrProvinceName' => $locality,
           'localityName' => $locality,
           'organizationName' => $organisation,
           'organizationalUnitName' => 'MNet',
           'commonName' => substr($wwwroot, 0, 64),
           'subjectAltName' => $wwwroot,
           'emailAddress' => $email
        );

        // ensure we remove trailing slashes
        $dn['commonName'] = preg_replace(':/$:', '', $dn['commonName']);

        $new_key = openssl_pkey_new();
        $csr_rsc = openssl_csr_new($dn, $new_key, array('private_key_bits',2048));
        $selfSignedCert = openssl_csr_sign($csr_rsc, null, $new_key, intval($days));
        unset($csr_rsc); // Free up the resource

        // We export our self-signed certificate to a string.
        openssl_x509_export($selfSignedCert, $keypair['certificate']);
        openssl_x509_free($selfSignedCert);

        // Export our public/private key pair as a PEM encoded string
        openssl_pkey_export($new_key, $keypair['keypair_PEM']);
        openssl_pkey_free($new_key);
        unset($new_key); // Free up the resource

        return $keypair;
    }
}

/**
 * General exception thrown by MNet in case of various errors
 */
class mnet_exception extends Exception {}

/**
 * Represents an XML-RPC fault as an exception
 * 
 */
class mnet_xmlrpc_fault extends Exception {
    /**
	 * Construt a new XML-RPC fault. Each fault requires a
	 * code and a message
	 * 
	 * @param int $code the numeric, machine-readable code of the fault (integer)
	 * @param string $message what went wrong, for humans
     */
    public function __construct($code, $message) {
        $this->code    = $code;
        $this->message = $message;
    }
    
    /**
     * Stringifier for XMLRPC fault
     *
     * @return string the code and message
     */
    public function __toString() {
        return 'Code: '.$this->code.': '.$this->message;
    }
}
